Skip to content

feat: render and time a real basemap in the browser gate, and emit MapLibre's worker (#63) - #314

Merged
mgcronin merged 1 commit into
mainfrom
feature/issue-63-basemap-fixture-and-cold-load
Sep 16, 2026
Merged

mgcronin merged 1 commit into
mainfrom
feature/issue-63-basemap-fixture-and-cold-load

Conversation

@mgcronin

Copy link
Copy Markdown
Contributor

What this is

Refs #63. Deliberately not a closing keyword — criterion 8 is only partly discharged and criterion 1's second half depends on a hosted archive that does not exist. CLAUDE.md §7 is explicit that the keyword has to be right when the PR is opened, and both prior comments on #63 left it open for exactly this reason (ADR 0010's open question 3 names #63 as the owner of the cold-load measurement, and an ADR body is never edited in place). Refs #53 as well, which owns the other half.

#63 has had seven of eight criteria since #175/#176. The eighth — "Cold-load behaviour is measured and recorded in the PR: time to first painted tile on a cold cache" — could not be taken, because there was no archive, so no tile was ever painted and there was nothing to time. map.browser.spec.ts said so, and predicted the fix: "the day #53 lands, that test goes red — which is the right moment for somebody to come back and replace it with one that asserts tiles actually drew."

This is that moment arriving from the other direction. The client's share of a cold load is ours, it does not need a host, and measuring it is what will let the hosted number — when there is one — be read as latency rather than as an unexplained total.

The defect this found

A production map fetched every tile and drew none of them, silently.

MapLibre v6 parses every vector tile in a Web Worker and locates it with new URL('./maplibre-gl-worker.mjs', import.meta.url). Under a bundler import.meta.url is the hashed chunk MapLibre was bundled into, and the expression is built from a variable, so it is not statically analysable and no bundler emits the file. The script 404s, the Worker object is constructed anyway, every loadTile message is sent into it and never answered, and the tile sits in loading for ever.

Every symptom is an absence. No exception, create returns normally, live GL context, correct origin, correct range request, registrations === 1, no map error. The existing browser gate was green through all of it — and was right to be, because there was no tile for the worker to fail to parse.

RESP 404 http://127.0.0.1:4319/assets/maplibre-gl-worker.mjs
REQFAIL     http://127.0.0.1:4319/assets/maplibre-gl-worker.mjs net::ERR_FAILED
RESP 206 http://127.0.0.1:4319/basemap-fixture.pmtiles bytes 0-519/520

The fix is one line in apps/web/src/map/maplibre.ts:

import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
setWorkerUrl(workerUrl);

⚠️ ?worker&url and not ?url. The dist worker imports its sibling maplibre-gl-shared.mjs; a verbatim copy of the one file fails on its first import and produces the same blank map by a different route. pnpm run build now emits assets/maplibre-gl-worker-*.js (486 kB), referenced only from the lazy map chunk — the code split is unchanged and the entry chunk still names nothing but the chunk filename.

This is a known MapLibre v6 + bundler trap rather than a discovery: see organicmaps/gtfs-osm-matcher#167, f5/unovis#921 and openwatersio/openwaters.io#122. It was reached here from the symptom, then confirmed against those.

What was built

File What it is
apps/web/browser/pmtiles-fixture.ts a PMTiles v3 archive written from arithmetic: a 127-byte header, a one-entry root directory whose run length spans every tile id from z0 to z14, and one MVT of three rectangles and a line. Uncompressed, so no gzip and no DecompressionStream
apps/web/browser/pmtiles-fixture.test.ts reads it back through pmtiles' own PMTiles class — the class maplibre.ts hands to addProtocol — plus an independent 40-line protobuf reader for the tile body
apps/web/browser/harness.ts reads the drawing buffer each frame and times the first painted tile; publishes window.__oylMapLoad
apps/web/browser/map.browser.spec.ts a new describe: the render, its control, and the measurement
apps/web/vite.browser.config.ts emits the archive into browser/dist at build time
apps/web/vitest.config.ts includes browser/**/*.test.ts, the precedent being packages/fit's tools/**/*.test.ts

Three decisions worth a reviewer's attention:

  • The archive is not committed. browser/dist is already gitignored and already pruned by check-repo-rules.sh. A binary in the tree would need an .spdx-exempt entry §3a would refuse it, and nobody can read it in review.
  • It is served at /basemap-fixture.pmtiles, not /basemap.pmtiles. The "Publish and serve the PMTiles basemap and routing engine #53 has published nothing" test asserts the 404 at the latter and stays exactly as it was. Moving the fixture there would make a tripwire permanently green against a file of our own — a test that cannot fail.
  • It contains no OpenStreetMap data. ODbL attribution attaches to a Produced Work derived from OSM; a fixture carrying a scrap of real coastline would quietly make a build artefact a derivative. The metadata section deliberately carries no attribution field. The product's own credit is a separate criterion and is untouched.

Criterion 8 — the measurement, and what it leaves out

From the gate's own log, printed on every run (and annotated):

cold load — first painted tile 186.7 ms after the map was created,
222.2 ms after navigation started; 3 archive range request(s) costing
3.2 ms in total; 7 frames probed; served from the loopback interface,
so this is the client-side floor and carries none of the hosting
latency #53 owns

⚠️ This does not discharge criterion 8 and is not claimed to. Its last sentence points at hosting — ADR 0010 D-1 quotes Protomaps' warning that R2 is "known to have higher latency (500 ms or higher)" — and a loopback server removes that term by construction. The tile is uncompressed and 98 bytes; a real one is gzipped and thousands of times larger. This is a headless Chromium on a software rasteriser. What it gives is the floor, a decomposition (the range requests are reported beside the total), and a harness already in place to take the hosted measurement.

⚠️ One number worth carrying into #53: three range requests, not two. The header and root directory arrive together in one 16 KB prefetch and are cached for the life of the page; tiles are not — SharedPromiseCache caches headers and directories only, so every visible tile is its own range request even when they all resolve to the same bytes. On a high-latency store that multiplier is the cold-load cost. A first draft of the comment asserted "two round trips, no more"; the run said three, and the comment now says what the run said.

⚠️ No wall-clock threshold is asserted and one must not be added. Only sanity bounds. An absolute millisecond gate on a shared runner is flaky, a flaky gate gets disabled, and a disabled gate is how a performance claim survives with nobody re-running it — the reasoning game.browser.spec.ts already records for the shading cost, followed here.

How the paint is read

preserveDrawingBuffer is off in shipping code and must stay off — it is a per-frame cost paid by every rider so a test can read a pixel. The harness forces it on for its own page by patching HTMLCanvasElement.prototype.getContext before the map is built.

⚠️ The obvious alternative was tried and does not work, recorded so nobody tries it again: wrapping window.requestAnimationFrame before the map exists, so the probe runs immediately after MapLibre's frame callback. Every sample came back #000000. MapLibre v6 reaches its renderer through browser.frameAsync, which resolves a promise from the frame callback, so the draw happens in a microtask after anything chained synchronously onto it — the probe was reading a buffer that had already been presented and cleared.

The cost of the readback is in the reported number, and is identical on the fixture page and the control page, so the two remain comparable.

Mutations — §5's gate

Each mutation was applied to a clean tree, the suite run, and the file restored byte-identically.

The fixture, against pmtiles-fixture.test.ts

# Mutation Went red
M1 geometry written as raw bytes instead of packed varints draws its polygons the way round that makes them exterior rings — this is the real defect this test caught: the first version handed ring()'s command integers to the byte writer, 8448 stored as 0 and 8447 as 255, and every other assertion stayed green because the tile still parsed into the right number of vertices at the wrong places
M2 polygon rings wound the other way same test. MVT 2.1 §4.3.3.3 reads the sign of the surveyor's formula as exterior-vs-hole; reversed, the archive is well-formed, PMTiles hands the tile over, and MapLibre draws nothing
M3 run length set to 1, so only tile 0/0/0 resolves is readable by the decoder the client actually ships, answers for every tile from zoom zero to its declared maximum
M4 directory offset column stores the offset rather than offset + 1 answers for every tile … — a stored 0 reads back as offset −1
M5 a fixture layer renamed away from the style's source-layer fills every source layer the real style reads, and no other, carries one feature that is not a polygon
M6 header maxZoom written as 0 while the run length still spans the pyramid is readable by the decoder …, answers for every tile …
M7 the tile's declared extent omitted, falling back to MapLibre's default decodes as a version 2 vector tile with the extent it declares

The gate, against map.browser.spec.ts

# Mutation Went red
M8 setWorkerUrl(workerUrl) removed — the production fix reverted paints the archive's own tiles under the ride trace and records what a cold load costs the client, with no basemap colour reached the drawing buffer. Looked for #eeece7, #b9d4e8, #ffffff; read #f5f3ef ×10 over 300 frames. Every one of the six pre-existing browser tests stayed green, which is the measurement of why this defect survived: the old gate could not see it
M9 the control page pointed at the fixture archive paints no tile colour at all when the archive is missing — the control. The control can fail
M10 the style's background colour counted as a tile colour in the harness palette the control, and the measurement. The background-vs-tile split is load-bearing
M11 preserveTheDrawingBuffer() removed all three, including the control's the samples contain the background colour with #000000 ×10. This is the one that proves the control is not passing by reading nothing

Gates

Every command below was run on this branch at the pinned Node 24.20.0 / pnpm 11.18.0, against Chromium revision 1243 (the revision @playwright/test 1.63.0 pins).

Gate Result
pnpm run check:repo 115 + 16 + 21 + 28 passed, 0 failed
shellcheck scripts/*.sh clean
pnpm run format:check clean
pnpm run lint clean
pnpm run typecheck clean, all 7 packages
pnpm run test:coverage green
pnpm run check:a11y-suite 17 accessibility test files, all selected
pnpm run test:a11y 17 files, 304 tests
bash scripts/check-a11y-suite.test.sh 29 passed
pnpm run check:wiring 40 watched files, 270 production modules, all reachable
bash scripts/check-wiring.test.sh 56 passed
pnpm run check:capacitor regenerated and byte-identical
bash scripts/check-capacitor-generated.test.sh 53 passed
bash scripts/coverage-summary.test.sh 17 passed
pnpm run build clean
pnpm run test:browser 74 passed (was 71)
bash scripts/check-dependency-licences.test.sh 49 passed
pnpm run check:licences 868 licences across 7 packages, all permitted

Coverage

Reported, not gated — the gate is the mutation list above.

before after
workspace statements 95.22% (12216/12828) 95.22% (12216/12829)
apps/web statements 92.77% 92.75%

The denominator moves by one and the numerator does not: setWorkerUrl(workerUrl) is a production statement that only executes in a real browser, and the browser gate is not instrumented. apps/web/browser/** is outside the coverage report's apps/*/src/** patterns, the same way packages/fit/tools/** is (#110) and for the same reason — a fixture generator's coverage does not belong in a client's denominator.

Security

  • No dependency added, removed or bumped. pnpm-lock.yaml and every package.json are untouched; check:licences re-run and green.
  • Nothing shipped changed but one configuration call, whose argument is a build-time constant emitted by the bundler onto our own origin. No user input reaches it.
  • The ?archive= and ?paintDeadline= switches are harness-only (apps/web/browser/), which vite.browser.config.ts keeps out of the shipping bundle by being a second config; the product reads its archive URL from VITE_BASEMAP_PMTILES_URL as before.
  • Criterion 3 re-checked on a page where tiles actually flowed, rather than one whose source 404d: every request is asserted to be on the configured origin. That is a stronger execution of "intercepts all network traffic during a map render" than was previously possible.
  • No location data, no cross-athlete read, no persistence path, no file parsing of user input, no trainer control. The one new parser (protobuf/PMTiles) runs only in a test and a build script, over bytes this repository generated.

Deliberately not done

🤖 Generated with Claude Code

https://claude.ai/code/session_016X4EwacF1KuXDPhXzunifS

#63's browser gate proved the routing to a basemap archive and not the
picture, because there was no archive to render: #53 publishes one and is
blocked on #52, which is Phase 3. This builds a PMTiles v3 archive from
arithmetic instead, emits it into the harness build, and renders from it.

What that immediately found is a defect in shipping code. MapLibre v6 parses
every vector tile in a Web Worker and locates it with
`new URL('./maplibre-gl-worker.mjs', import.meta.url)`, which under a bundler
resolves against the hashed chunk MapLibre landed in — a file no bundler emits,
because the expression is built from a variable. The script 404s, the Worker is
constructed anyway, every tile-parse message goes into it unanswered, and a
production map fetches all its tiles and draws none of them with no error
anywhere. `maplibre.ts` now calls `setWorkerUrl` with a `?worker&url` import;
`?url` alone would not do, because the dist worker imports its sibling
`maplibre-gl-shared.mjs`.

The cold-load measurement #63's eighth criterion asks for is taken and printed
on every run, and is explicitly only the client-side floor: served from the
loopback interface, it carries none of the hosting latency that criterion is
pointed at. That half still belongs to #53.

- apps/web/browser/pmtiles-fixture.ts: the archive, written from the v3 spec
  and MVT 2.1; no OpenStreetMap data, so no attribution attaches to it
- apps/web/browser/harness.ts: reads the drawing buffer each frame and times
  the first painted tile, with `preserveDrawingBuffer` forced by the harness
- apps/web/browser/map.browser.spec.ts: the render, its control, and the number
- apps/web/vite.browser.config.ts: emits the archive at build time; never
  committed, and at a path that leaves the "#53 has published nothing" tripwire
  asserting its 404
- apps/web/vitest.config.ts: includes `browser/**/*.test.ts`, so the fixture's
  own unit test is run by something

Refs #63
Refs #53

Signed-off-by: Matthew Cronin <mgcronin@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X4EwacF1KuXDPhXzunifS
Signed-off-by: mcronin <mgcronin@gmail.com>
@mgcronin

Copy link
Copy Markdown
Contributor Author

Criterion 8, second data point — the CI runner

The number in the body was taken on a developer's laptop. Run 35046425989, ubuntu-latest, software rasteriser:

cold load — first painted tile 339.4 ms after the map was created,
456.5 ms after navigation started; 3 archive range request(s) costing
27.2 ms in total; 11 frames probed; served from the loopback interface,
so this is the client-side floor and carries none of the hosting
latency #53 owns

Both together, which is the more useful reading than either alone:

laptop ubuntu-latest
first painted tile, from map creation 186.7 ms 339.4 ms
first painted tile, from navigation start 222.2 ms 456.5 ms
archive range requests 3 3
total time in those requests 3.2 ms 27.2 ms

⚠️ The interesting column is the last one, and it is the one that most argues the measurement is worth having. Loopback HTTP moved from 3.2 ms to 27.2 ms — an eightfold change in the transport term on a link with no real latency at all, between two machines a few metres of abstraction apart. The request count did not move, because it is a property of the archive and the viewport rather than of the machine.

So the honest reading of the floor is: three sequential round trips before the first tile paints, whatever the host costs per round trip. On a store ADR 0010 D-1 records as 500 ms or higher, that is the figure #53's measurement will be dominated by, and it is a structural cost this PR can report but not reduce.

🤖 Generated with Claude Code

https://claude.ai/code/session_016X4EwacF1KuXDPhXzunifS

@mgcronin mgcronin left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #314

Recommendation: merge with follow-ups — 0 blocking findings, 6 non-blocking.

This is automated analysis, not a human sign-off. The authenticated gh identity is also the PR author, so GitHub cannot record an independent approval or change request from it and this is published as a comment. A human still owns the merge decision.

What I re-ran, on this branch, at Node 24.20.0 / pnpm 11.18.0 / Chromium 1243

Gate Result
pnpm install --frozen-lockfile already up to date
pnpm run check:repo 115 + 16 + 21 + 28 passed
pnpm run format:check clean
pnpm run lint clean
pnpm run typecheck clean, all 7 packages
pnpm run build clean — emits dist/assets/maplibre-gl-worker-CL9gHS1m.js (485.85 kB)
pnpm run check:wiring 40 watched files, 270 production modules, all reachable
pnpm run check:a11y-suite 17 files, all selected
pnpm --filter @onyourleft/web run test 129 files, 2105 tests passed
pnpm run test:browser 74 passed — matches the claim (was 71)
gh pr checks 314 Repository rules pass, CodeQL pass

The production defect is real, and the fix is load-bearing — verified, not accepted

I applied M8 myself on a clean tree (setWorkerUrl(workerUrl) commented out, nothing else) and re-ran the map spec:

1) paints the archive’s own tiles under the ride trace — criterion 1, and 7
   Error: no basemap colour reached the drawing buffer. Looked for #eeece7, #b9d4e8, #ffffff;
          read #f5f3ef ×10 over 300 frames
2) records what a cold load costs the client — criterion 8, as far as it goes
   expect(load.painted).toBe(true)  ->  false

2 failed, 7 passed

Exactly the two fixture tests red; the six pre-existing browser tests and the control green. That is the claim in the body, reproduced. I also confirmed the mechanism from the shipped bytes rather than from the write-up: maplibre-gl.mjs builds its worker URL as new URL(`./${t}`, e).href — a template with a variable, so not statically analysable — and maplibre-gl-worker.mjs's first line is an import of its sibling, which is why ?worker&url and not ?url. setWorkerUrl is an exported, documented API in maplibre-gl.d.ts. In pnpm run build the emitted worker is referenced only from assets/maplibre-C01SpLki.js, the lazy map chunk — the code split is genuinely unaffected.

I spot-checked two more mutations on a clean tree and restored byte-identically:

  • M1 (geometry as raw bytes, not packed varints) → only draws its polygons the way round that makes them exterior rings red, 9 passed. As described, including that it is the winding assertion that catches it.
  • M4 (offset column without +1) → only answers for every tile from zoom zero to its declared maximum red, 9 passed.

The mutation list is credible.

Requirements: PASS for what is claimed

Refs #63 rather than Closes #63 is correct under CLAUDE.md §7 — criterion 8's hosted half needs #53 → #52, and the ADR 0010 open-question reference would have been orphaned. Commit carries Signed-off-by:. No dependency added, removed or bumped; pnpm-lock.yaml and every manifest untouched. docs/adr/* untouched (protected path). No changelog convention exists here, correctly none added.

Security: CLEAN

No user input reaches the one shipping change; its argument is a bundler-emitted, same-origin constant. ?archive= and ?paintDeadline= live only in apps/web/browser/, which the second Vite config keeps out of the shipped bundle — I confirmed apps/web/dist contains no basemap-fixture.pmtiles and no harness entry. The new protobuf/PMTiles encoder runs only in a Vitest file and a build script, over bytes this repository generates. Criterion 3 is now executed on a page where tiles actually flowed, and the newly-appearing worker request is inside that assertion's reach.

Credit where it is due

Three things stand out. The fixture is served at /basemap-fixture.pmtiles rather than /basemap.pmtiles, so the #53 tripwire stays falsifiable instead of being discharged by a file of our own — that is the harder and the right call. The control page (paints no tile colour at all when the archive is missing) plus M11 is what stops this whole gate being green because nothing was measured, and it is the reason I believe the positive assertion. And FIXTURE_SOURCE_LAYERS being asserted equal to the style's own source-layer set means a future style layer fails here rather than silently going uncovered.

Non-blocking findings

Six, all inline below except the last, which is on a line this PR does not touch:

(6) INFO, pre-existing — not introduced here. CLAUDE.md:1286 still reads "There are four entries today, not two: the map, the game, the HUD and the capture tool." There are five in vite.browser.config.ts — shell arrived with #307. This PR edits §4f heavily, so carrying the one-word fix here would be cheap; equally fair to leave it for whoever next touches §4f.

Scope of this review

Read all ten changed files in full, plus apps/web/src/map/basemap.test.ts, playwright.config.ts, vite.config.ts and the relevant maplibre-gl dist bytes. Ran the gates in the table above. Did not run check:capacitor, check:licences or test:coverage locally — no dependency or Capacitor surface changed and CI ran all three green. Did not exercise a hosted archive, which is the point of the PR's own caveat.

* requirement that the root lie inside the first 16,384 bytes — so a client can
* prefetch header and directory in one range request — is met by a very wide
* margin. That prefetch is the reason the cold-load number below is two round
* trips rather than three.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (documentation) — high confidence. This sentence is the claim the PR body says was falsified and corrected, left standing in the one file a reader is pointed at for provenance.

That prefetch is the reason the cold-load number below is two round
trips rather than three.

The run reports three archive range requests (I reproduced it: 3 archive range request(s) costing 3.2 ms in total), and map.browser.spec.ts:390-400 explicitly records that "a first draft of this comment asserted 'two round trips, no more'; the run reported three". SharedPromiseCache caches headers and directories, not tiles, so it is one request per visible tile — which the PR body itself calls the reusable finding for #53.

Secondary: "the cold-load number below" points at nothing in this file; the number is printed by the spec.

This matters more here than it would elsewhere, because CLAUDE.md's own convention is that a sentence which has become false is a defect rather than untidiness — and pmtiles-fixture.ts's header is where §4f now sends a reader before quoting the number.

* From immediately before `renderer.create` to the frame that painted.
*
* The **client's** share of a cold load: a range request for the header and
* root directory, a range request for the tile, the MVT decode, and the first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (documentation) — high confidence. The same two-request framing as pmtiles-fixture.ts:487, one layer down:

The client's share of a cold load: a range request for the header and
root directory, a range request for the tile, the MVT decode, and the first
draw.

Measured, it is a range request for the header and root directory and then one per visible tile — three on the run this gate performs. Since archiveRequests is published right beside firstPaintMs precisely so the hosted number can be decomposed, the doc comment describing the decomposition is the one place it is worth being exact. Suggest "…and a range request per visible tile (three on this gate's viewport — SharedPromiseCache caches headers and directories, not tiles)".

* this module is only evaluated when a map is actually wanted — `main.tsx`
* reaches it through `import()`.
*/
setWorkerUrl(workerUrl);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified rather than accepted: I reverted only this line on an otherwise clean tree and the two fixture tests went red with read #f5f3ef ×10 over 300 frames while all six pre-existing browser tests and the control stayed green. The defect and the fix are both real, and the reasoning about ?worker&url vs ?url checks out against the installed maplibre-gl-worker.mjs, whose first statement is an import of maplibre-gl-shared.mjs. Good find, and the note above it is the right length for what it costs to rediscover.

INFO / follow-up, non-blocking. One consequence worth writing down somewhere durable: the only thing that can catch a regression here builds with vite.browser.config.ts, not with the app's own vite.config.ts. A change to base, build.assetsDir or worker.format in vite.config.ts could break the shipped worker URL while the harness build stays green, and apps/web/browser/** is outside the coverage report so nothing else would notice either. I checked by hand on this branch — pnpm run build emits assets/maplibre-gl-worker-CL9gHS1m.js, referenced only from the lazy assets/maplibre-*.js chunk, so today it is correct. A one-line assertion over the built dist (the worker asset exists and is named by the map chunk) would close it, or a sentence in docs/architecture.md §"The map dependencies" saying which config the gate actually builds.

// The archive is named in the URL rather than in the code, which is
// criterion 7 — *"the basemap URL is configuration, and a test proves the
// map renders against a second archive URL without a code change"* —
// executed by a real engine rather than by a style comparison. Nothing in

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INFO — medium confidence, a wording point rather than a defect. The title says "criterion 1, and 7" and this comment reads criterion 7 as executed here. What the harness actually varies is its own ?archive= switch: harness.ts:27-36 records that it constructs a BasemapConfig directly and deliberately bypasses readBasemapConfig, because that function refuses a non-https: URL. The product's configuration path — VITE_BASEMAP_PMTILES_URL → readBasemapConfig → basemapStyle — is covered in basemap.test.ts:88 ("renders against a second archive with no code change — criterion 7") and is not exercised on this page.

That is a perfectly good additional execution of the claim in a real engine, and it is worth having. It is just worth one clause saying which half it is, so a later reader does not take this as the config reader being proved end to end — the same care the file already takes over styleOrigins not being subsumed.

expect(walk(roads?.features[0]?.commands ?? []).length).toBe(2);
// And it is not closed: a `ClosePath` here would make MapLibre read a
// degenerate ring rather than a line.
expect(roads?.features[0]?.commands).not.toContain(15);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: not.toContain(15) is asserted over the raw command and parameter stream, so it would also fail if a future geometry produced a parameter varint that happened to equal 15 (zigzag 15 is a delta of −8). Today's road line encodes [9, 127, 4096, 10, 8448, 0], so it cannot happen — but the assertion reads as "no ClosePath" and is not quite that.

walk() already separates commands from parameters; asserting on the command ids it steps over would say the intended thing exactly. Entirely optional.

if (first === undefined) {
throw new RangeError('a line needs at least one vertex');
}
commands.push(9, zigzag(first.x), zigzag(first.y));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: line() writes its first vertex as zigzag(first.x) where ring() (line 289) writes zigzag(first.x - cursorX) with the cursor at zero. The two are identical in effect, but the asymmetry makes a reader stop and check whether one of them is the bug — in a file whose whole point is that a silently wrong parameter integer still produces a well-formed archive. Matching the two spellings would cost nothing.

@mgcronin
mgcronin merged commit c7258d1 into main Sep 16, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant